// ========================================================================
// Copyright (c) 2006-2009 Mort Bay Consulting Pty. Ltd.
// ------------------------------------------------------------------------
// All rights reserved. This program and the accompanying materials
// are made available under the terms of the Eclipse Public License v1.0
// and Apache License v2.0 which accompanies this distribution.
// The Eclipse Public License is available at 
// http://www.eclipse.org/legal/epl-v10.html
// The Apache License v2.0 is available at
// http://www.opensource.org/licenses/apache2.0.php
// You may elect to redistribute this code under either of these licenses. 
// ========================================================================

package org.eclipse.jetty.util;

import java.io.File;
import java.io.FilenameFilter;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Set;
import java.util.Timer;
import java.util.TimerTask;

import org.eclipse.jetty.util.component.AbstractLifeCycle;
import org.eclipse.jetty.util.log.Log;
import org.eclipse.jetty.util.log.Logger;

/**
 * Scanner Utility for scanning a directory for added, removed and changed files and reporting these events via registered Listeners.
 */
public class Scanner extends AbstractLifeCycle
{

	private static final Logger LOG = Log.getLogger(Scanner.class);
	private static int __scannerId = 0;
	private int _scanInterval;
	private int _scanCount = 0;
	private final List<Listener> _listeners = new ArrayList<Listener>();
	private final Map<String, TimeNSize> _prevScan = new HashMap<String, TimeNSize>();
	private final Map<String, TimeNSize> _currentScan = new HashMap<String, TimeNSize>();
	private FilenameFilter _filter;
	private final List<File> _scanDirs = new ArrayList<File>();
	private volatile boolean _running = false;
	private boolean _reportExisting = true;
	private boolean _reportDirs = true;
	private Timer _timer;
	private TimerTask _task;
	private int _scanDepth = 0;

	public enum Notification {
		ADDED, CHANGED, REMOVED
	};

	private final Map<String, Notification> _notifications = new HashMap<String, Notification>();

	static class TimeNSize
	{

		final long _lastModified;
		final long _size;

		public TimeNSize(long lastModified, long size)
		{
			_lastModified = lastModified;
			_size = size;
		}

		@Override
		public int hashCode()
		{
			return (int)_lastModified ^ (int)_size;
		}

		@Override
		public boolean equals(Object o)
		{
			if (o instanceof TimeNSize)
			{
				TimeNSize tns = (TimeNSize)o;
				return tns._lastModified == _lastModified && tns._size == _size;
			}
			return false;
		}

		@Override
		public String toString()
		{
			return "[lm=" + _lastModified + ",s=" + _size + "]";
		}
	}

	/**
	 * Listener Marker for notifications re file changes.
	 */
	public interface Listener
	{}

	public interface ScanListener extends Listener
	{

		public void scan();
	}

	public interface DiscreteListener extends Listener
	{

		public void fileChanged(String filename) throws Exception;

		public void fileAdded(String filename) throws Exception;

		public void fileRemoved(String filename) throws Exception;
	}

	public interface BulkListener extends Listener
	{

		public void filesChanged(List<String> filenames) throws Exception;
	}

	/**
	 * Listener that notifies when a scan has started and when it has ended.
	 */
	public interface ScanCycleListener extends Listener
	{

		public void scanStarted(int cycle) throws Exception;

		public void scanEnded(int cycle) throws Exception;
	}

	/**
     * 
     */
	public Scanner()
	{}

	/**
	 * Get the scan interval
	 * 
	 * @return interval between scans in seconds
	 */
	public int getScanInterval()
	{
		return _scanInterval;
	}

	/**
	 * Set the scan interval
	 * 
	 * @param scanInterval pause between scans in seconds, or 0 for no scan after the initial scan.
	 */
	public synchronized void setScanInterval(int scanInterval)
	{
		_scanInterval = scanInterval;
		schedule();
	}

	/**
	 * Set the location of the directory to scan.
	 * 
	 * @param dir
	 * @deprecated use setScanDirs(List dirs) instead
	 */
	@Deprecated
	public void setScanDir(File dir)
	{
		_scanDirs.clear();
		_scanDirs.add(dir);
	}

	/**
	 * Get the location of the directory to scan
	 * 
	 * @return the first directory (of {@link #getScanDirs()} being scanned)
	 * @deprecated use getScanDirs() instead
	 */
	@Deprecated
	public File getScanDir()
	{
		return (_scanDirs == null ? null : (File)_scanDirs.get(0));
	}

	public void setScanDirs(List<File> dirs)
	{
		_scanDirs.clear();
		_scanDirs.addAll(dirs);
	}

	public synchronized void addScanDir(File dir)
	{
		_scanDirs.add(dir);
	}

	public List<File> getScanDirs()
	{
		return Collections.unmodifiableList(_scanDirs);
	}

	/* ------------------------------------------------------------ */
	/**
	 * @param recursive True if scanning is recursive
	 * @see #setScanDepth(int)
	 */
	public void setRecursive(boolean recursive)
	{
		_scanDepth = recursive ? -1 : 0;
	}

	/* ------------------------------------------------------------ */
	/**
	 * @return True if scanning is fully recursive (scandepth==-1)
	 * @see #getScanDepth()
	 */
	public boolean getRecursive()
	{
		return _scanDepth == -1;
	}

	/* ------------------------------------------------------------ */
	/**
	 * Get the scanDepth.
	 * 
	 * @return the scanDepth
	 */
	public int getScanDepth()
	{
		return _scanDepth;
	}

	/* ------------------------------------------------------------ */
	/**
	 * Set the scanDepth.
	 * 
	 * @param scanDepth the scanDepth to set
	 */
	public void setScanDepth(int scanDepth)
	{
		_scanDepth = scanDepth;
	}

	/**
	 * Apply a filter to files found in the scan directory. Only files matching the filter will be reported as added/changed/removed.
	 * 
	 * @param filter
	 */
	public void setFilenameFilter(FilenameFilter filter)
	{
		_filter = filter;
	}

	/**
	 * Get any filter applied to files in the scan dir.
	 * 
	 * @return the filename filter
	 */
	public FilenameFilter getFilenameFilter()
	{
		return _filter;
	}

	/* ------------------------------------------------------------ */
	/**
	 * Whether or not an initial scan will report all files as being added.
	 * 
	 * @param reportExisting if true, all files found on initial scan will be reported as being added, otherwise not
	 */
	public void setReportExistingFilesOnStartup(boolean reportExisting)
	{
		_reportExisting = reportExisting;
	}

	/* ------------------------------------------------------------ */
	public boolean getReportExistingFilesOnStartup()
	{
		return _reportExisting;
	}

	/* ------------------------------------------------------------ */
	/**
	 * Set if found directories should be reported.
	 * 
	 * @param dirs
	 */
	public void setReportDirs(boolean dirs)
	{
		_reportDirs = dirs;
	}

	/* ------------------------------------------------------------ */
	public boolean getReportDirs()
	{
		return _reportDirs;
	}

	/* ------------------------------------------------------------ */
	/**
	 * Add an added/removed/changed listener
	 * 
	 * @param listener
	 */
	public synchronized void addListener(Listener listener)
	{
		if (listener == null)
			return;
		_listeners.add(listener);
	}

	/**
	 * Remove a registered listener
	 * 
	 * @param listener the Listener to be removed
	 */
	public synchronized void removeListener(Listener listener)
	{
		if (listener == null)
			return;
		_listeners.remove(listener);
	}

	/**
	 * Start the scanning action.
	 */
	@Override
	public synchronized void doStart()
	{
		if (_running)
			return;

		_running = true;

		if (_reportExisting)
		{
			// if files exist at startup, report them
			scan();
			scan(); // scan twice so files reported as stable
		}
		else
		{
			//just register the list of existing files and only report changes
			scanFiles();
			_prevScan.putAll(_currentScan);
		}
		schedule();
	}

	public TimerTask newTimerTask()
	{
		return new TimerTask()
		{

			@Override
			public void run() {
				scan();
			}
		};
	}

	public Timer newTimer()
	{
		return new Timer("Scanner-" + __scannerId++, true);
	}

	public void schedule()
	{
		if (_running)
		{
			if (_timer != null)
				_timer.cancel();
			if (_task != null)
				_task.cancel();
			if (getScanInterval() > 0)
			{
				_timer = newTimer();
				_task = newTimerTask();
				_timer.schedule(_task, 1010L * getScanInterval(), 1010L * getScanInterval());
			}
		}
	}

	/**
	 * Stop the scanning.
	 */
	@Override
	public synchronized void doStop()
	{
		if (_running)
		{
			_running = false;
			if (_timer != null)
				_timer.cancel();
			if (_task != null)
				_task.cancel();
			_task = null;
			_timer = null;
		}
	}

	/**
	 * Perform a pass of the scanner and report changes
	 */
	public synchronized void scan()
	{
		reportScanStart(++_scanCount);
		scanFiles();
		reportDifferences(_currentScan, _prevScan);
		_prevScan.clear();
		_prevScan.putAll(_currentScan);
		reportScanEnd(_scanCount);

		for (Listener l: _listeners)
		{
			try
			{
				if (l instanceof ScanListener)
					((ScanListener)l).scan();
			} catch (Exception e)
			{
				LOG.warn(e);
			} catch (Error e)
			{
				LOG.warn(e);
			}
		}
	}

	/**
	 * Recursively scan all files in the designated directories.
	 */
	public synchronized void scanFiles()
	{
		if (_scanDirs == null)
			return;

		_currentScan.clear();
		Iterator<File> itor = _scanDirs.iterator();
		while (itor.hasNext())
		{
			File dir = itor.next();

			if ((dir != null) && (dir.exists()))
				try
				{
					scanFile(dir.getCanonicalFile(), _currentScan, 0);
				} catch (IOException e)
				{
					LOG.warn("Error scanning files.", e);
				}
		}
	}

	/**
	 * Report the adds/changes/removes to the registered listeners
	 * 
	 * @param currentScan the info from the most recent pass
	 * @param oldScan info from the previous pass
	 */
	@SuppressWarnings("incomplete-switch")
	public synchronized void reportDifferences(Map<String, TimeNSize> currentScan, Map<String, TimeNSize> oldScan)
	{
		// scan the differences and add what was found to the map of notifications:

		Set<String> oldScanKeys = new HashSet<String>(oldScan.keySet());

		// Look for new and changed files
		for (Map.Entry<String, TimeNSize> entry: currentScan.entrySet())
		{
			String file = entry.getKey();
			if (!oldScanKeys.contains(file))
			{
				Notification old = _notifications.put(file, Notification.ADDED);
				if (old != null)
				{
					switch (old)
					{
						case REMOVED:
						case CHANGED:
							_notifications.put(file, Notification.CHANGED);
					}
				}
			}
			else if (!oldScan.get(file).equals(currentScan.get(file)))
			{
				Notification old = _notifications.put(file, Notification.CHANGED);
				if (old != null)
				{
					switch (old)
					{
						case ADDED:
							_notifications.put(file, Notification.ADDED);
					}
				}
			}
		}

		// Look for deleted files
		for (String file: oldScan.keySet())
		{
			if (!currentScan.containsKey(file))
			{
				Notification old = _notifications.put(file, Notification.REMOVED);
				if (old != null)
				{
					switch (old)
					{
						case ADDED:
							_notifications.remove(file);
					}
				}
			}
		}

		if (LOG.isDebugEnabled())
			LOG.debug("scanned " + _scanDirs + ": " + _notifications);

		// Process notifications
		// Only process notifications that are for stable files (ie same in old and current scan).
		List<String> bulkChanges = new ArrayList<String>();
		for (Iterator<Entry<String, Notification>> iter = _notifications.entrySet().iterator(); iter.hasNext();)
		{
			Entry<String, Notification> entry = iter.next();
			String file = entry.getKey();

			// Is the file stable?
			if (oldScan.containsKey(file))
			{
				if (!oldScan.get(file).equals(currentScan.get(file)))
					continue;
			}
			else if (currentScan.containsKey(file))
				continue;

			// File is stable so notify
			Notification notification = entry.getValue();
			iter.remove();
			bulkChanges.add(file);
			switch (notification)
			{
				case ADDED:
					reportAddition(file);
					break;
				case CHANGED:
					reportChange(file);
					break;
				case REMOVED:
					reportRemoval(file);
					break;
			}
		}
		if (!bulkChanges.isEmpty())
			reportBulkChanges(bulkChanges);
	}

	/**
	 * Get last modified time on a single file or recurse if the file is a directory.
	 * 
	 * @param f file or directory
	 * @param scanInfoMap map of filenames to last modified times
	 */
	private void scanFile(File f, Map<String, TimeNSize> scanInfoMap, int depth)
	{
		try
		{
			if (!f.exists())
				return;

			if (f.isFile() || depth > 0 && _reportDirs && f.isDirectory())
			{
				if ((_filter == null) || ((_filter != null) && _filter.accept(f.getParentFile(), f.getName())))
				{
					String name = f.getCanonicalPath();
					scanInfoMap.put(name, new TimeNSize(f.lastModified(), f.length()));
				}
			}

			// If it is a directory, scan if it is a known directory or the depth is OK.
			if (f.isDirectory() && (depth < _scanDepth || _scanDepth == -1 || _scanDirs.contains(f)))
			{
				File[] files = f.listFiles();
				for (int i = 0; i < files.length; i++)
					scanFile(files[i], scanInfoMap, depth + 1);
			}
		} catch (IOException e)
		{
			LOG.warn("Error scanning watched files", e);
		}
	}

	private void warn(Object listener, String filename, Throwable th)
	{
		LOG.warn(listener + " failed on '" + filename, th);
	}

	/**
	 * Report a file addition to the registered FileAddedListeners
	 * 
	 * @param filename
	 */
	private void reportAddition(String filename)
	{
		Iterator<Listener> itor = _listeners.iterator();
		while (itor.hasNext())
		{
			Listener l = itor.next();
			try
			{
				if (l instanceof DiscreteListener)
					((DiscreteListener)l).fileAdded(filename);
			} catch (Exception e)
			{
				warn(l, filename, e);
			} catch (Error e)
			{
				warn(l, filename, e);
			}
		}
	}

	/**
	 * Report a file removal to the FileRemovedListeners
	 * 
	 * @param filename
	 */
	private void reportRemoval(String filename)
	{
		Iterator<Listener> itor = _listeners.iterator();
		while (itor.hasNext())
		{
			Object l = itor.next();
			try
			{
				if (l instanceof DiscreteListener)
					((DiscreteListener)l).fileRemoved(filename);
			} catch (Exception e)
			{
				warn(l, filename, e);
			} catch (Error e)
			{
				warn(l, filename, e);
			}
		}
	}

	/**
	 * Report a file change to the FileChangedListeners
	 * 
	 * @param filename
	 */
	private void reportChange(String filename)
	{
		Iterator<Listener> itor = _listeners.iterator();
		while (itor.hasNext())
		{
			Listener l = itor.next();
			try
			{
				if (l instanceof DiscreteListener)
					((DiscreteListener)l).fileChanged(filename);
			} catch (Exception e)
			{
				warn(l, filename, e);
			} catch (Error e)
			{
				warn(l, filename, e);
			}
		}
	}

	private void reportBulkChanges(List<String> filenames)
	{
		Iterator<Listener> itor = _listeners.iterator();
		while (itor.hasNext())
		{
			Listener l = itor.next();
			try
			{
				if (l instanceof BulkListener)
					((BulkListener)l).filesChanged(filenames);
			} catch (Exception e)
			{
				warn(l, filenames.toString(), e);
			} catch (Error e)
			{
				warn(l, filenames.toString(), e);
			}
		}
	}

	/**
	 * signal any scan cycle listeners that a scan has started
	 */
	private void reportScanStart(int cycle)
	{
		for (Listener listener: _listeners)
		{
			try
			{
				if (listener instanceof ScanCycleListener)
				{
					((ScanCycleListener)listener).scanStarted(cycle);
				}
			} catch (Exception e)
			{
				LOG.warn(listener + " failed on scan start for cycle " + cycle, e);
			}
		}
	}

	/**
	 * sign
	 */
	private void reportScanEnd(int cycle)
	{
		for (Listener listener: _listeners)
		{
			try
			{
				if (listener instanceof ScanCycleListener)
				{
					((ScanCycleListener)listener).scanEnded(cycle);
				}
			} catch (Exception e)
			{
				LOG.warn(listener + " failed on scan end for cycle " + cycle, e);
			}
		}
	}

}
